<!--
	Installed from https://vue-bits.dev/ui/
-->

<template>
	<div ref="hyperspeedContainer" class="hyperspeed-container" />
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, watch, useTemplateRef } from "vue";
import * as THREE from "three";
import {
	BloomEffect,
	EffectComposer,
	EffectPass,
	RenderPass,
	SMAAEffect,
	SMAAPreset,
} from "postprocessing";

interface Distortion {
	uniforms: Record<string, { value: unknown }>;
	getDistortion: string;
	getJS?: (progress: number, time: number) => THREE.Vector3;
}

interface Distortions {
	[key: string]: Distortion;
}

interface Colors {
	roadColor: number;
	islandColor: number;
	background: number;
	shoulderLines: number;
	brokenLines: number;
	leftCars: number[];
	rightCars: number[];
	sticks: number;
}

interface HyperspeedOptions {
	onSpeedUp?: (ev: MouseEvent) => void;
	onSlowDown?: (ev: MouseEvent) => void;
	distortion?: string | Distortion;
	length: number;
	roadWidth: number;
	islandWidth: number;
	lanesPerRoad: number;
	fov: number;
	fovSpeedUp: number;
	speedUp: number;
	carLightsFade: number;
	totalSideLightSticks: number;
	lightPairsPerRoadWay: number;
	shoulderLinesWidthPercentage: number;
	brokenLinesWidthPercentage: number;
	brokenLinesLengthPercentage: number;
	lightStickWidth: [number, number];
	lightStickHeight: [number, number];
	movingAwaySpeed: [number, number];
	movingCloserSpeed: [number, number];
	carLightsLength: [number, number];
	carLightsRadius: [number, number];
	carWidthPercentage: [number, number];
	carShiftX: [number, number];
	carFloorSeparation: [number, number];
	colors: Colors;
	isHyper?: boolean;
}

interface HyperspeedProps {
	effectOptions?: Partial<HyperspeedOptions>;
}

const props = withDefaults(defineProps<HyperspeedProps>(), {
	effectOptions: () => ({}),
});

const hyperspeedContainer = useTemplateRef<HTMLDivElement>(
	"hyperspeedContainer",
);
let appRef: App | null = null;

const defaultOptions: HyperspeedOptions = {
	onSpeedUp: () => {},
	onSlowDown: () => {},
	distortion: "turbulentDistortion",
	length: 400,
	roadWidth: 10,
	islandWidth: 2,
	lanesPerRoad: 4,
	fov: 90,
	fovSpeedUp: 150,
	speedUp: 2,
	carLightsFade: 0.4,
	totalSideLightSticks: 20,
	lightPairsPerRoadWay: 40,
	shoulderLinesWidthPercentage: 0.05,
	brokenLinesWidthPercentage: 0.1,
	brokenLinesLengthPercentage: 0.5,
	lightStickWidth: [0.12, 0.5],
	lightStickHeight: [1.3, 1.7],
	movingAwaySpeed: [60, 80],
	movingCloserSpeed: [-120, -160],
	carLightsLength: [400 * 0.03, 400 * 0.2],
	carLightsRadius: [0.05, 0.14],
	carWidthPercentage: [0.3, 0.5],
	carShiftX: [-0.8, 0.8],
	carFloorSeparation: [0, 5],
	colors: {
		roadColor: 0x080808,
		islandColor: 0x0a0a0a,
		background: 0x000000,
		shoulderLines: 0xffffff,
		brokenLines: 0xffffff,
		leftCars: [0xd856bf, 0x6750a2, 0xc247ac],
		rightCars: [0x03b3c3, 0x0e5ea5, 0x324555],
		sticks: 0x03b3c3,
	},
};

function nsin(val: number) {
	return Math.sin(val) * 0.5 + 0.5;
}

const mountainUniforms = {
	uFreq: { value: new THREE.Vector3(3, 6, 10) },
	uAmp: { value: new THREE.Vector3(30, 30, 20) },
};

const xyUniforms = {
	uFreq: { value: new THREE.Vector2(5, 2) },
	uAmp: { value: new THREE.Vector2(25, 15) },
};

const LongRaceUniforms = {
	uFreq: { value: new THREE.Vector2(2, 3) },
	uAmp: { value: new THREE.Vector2(35, 10) },
};

const turbulentUniforms = {
	uFreq: { value: new THREE.Vector4(4, 8, 8, 1) },
	uAmp: { value: new THREE.Vector4(25, 5, 10, 10) },
};

const deepUniforms = {
	uFreq: { value: new THREE.Vector2(4, 8) },
	uAmp: { value: new THREE.Vector2(10, 20) },
	uPowY: { value: new THREE.Vector2(20, 2) },
};

const distortions: Distortions = {
	mountainDistortion: {
		uniforms: mountainUniforms,
		getDistortion: `
      uniform vec3 uAmp;
      uniform vec3 uFreq;
      #define PI 3.14159265358979
      float nsin(float val){
        return sin(val) * 0.5 + 0.5;
      }
      vec3 getDistortion(float progress){
        float movementProgressFix = 0.02;
        return vec3( 
          cos(progress * PI * uFreq.x + uTime) * uAmp.x - cos(movementProgressFix * PI * uFreq.x + uTime) * uAmp.x,
          nsin(progress * PI * uFreq.y + uTime) * uAmp.y - nsin(movementProgressFix * PI * uFreq.y + uTime) * uAmp.y,
          nsin(progress * PI * uFreq.z + uTime) * uAmp.z - nsin(movementProgressFix * PI * uFreq.z + uTime) * uAmp.z
        );
      }
    `,
		getJS: (progress: number, time: number) => {
			const movementProgressFix = 0.02;
			const uFreq = mountainUniforms.uFreq.value;
			const uAmp = mountainUniforms.uAmp.value;
			const distortion = new THREE.Vector3(
				Math.cos(progress * Math.PI * uFreq.x + time) * uAmp.x -
					Math.cos(movementProgressFix * Math.PI * uFreq.x + time) * uAmp.x,
				nsin(progress * Math.PI * uFreq.y + time) * uAmp.y -
					nsin(movementProgressFix * Math.PI * uFreq.y + time) * uAmp.y,
				nsin(progress * Math.PI * uFreq.z + time) * uAmp.z -
					nsin(movementProgressFix * Math.PI * uFreq.z + time) * uAmp.z,
			);
			const lookAtAmp = new THREE.Vector3(2, 2, 2);
			const lookAtOffset = new THREE.Vector3(0, 0, -5);
			return distortion.multiply(lookAtAmp).add(lookAtOffset);
		},
	},
	xyDistortion: {
		uniforms: xyUniforms,
		getDistortion: `
      uniform vec2 uFreq;
      uniform vec2 uAmp;
      #define PI 3.14159265358979
      vec3 getDistortion(float progress){
        float movementProgressFix = 0.02;
        return vec3( 
          cos(progress * PI * uFreq.x + uTime) * uAmp.x - cos(movementProgressFix * PI * uFreq.x + uTime) * uAmp.x,
          sin(progress * PI * uFreq.y + PI/2. + uTime) * uAmp.y - sin(movementProgressFix * PI * uFreq.y + PI/2. + uTime) * uAmp.y,
          0.
        );
      }
    `,
		getJS: (progress: number, time: number) => {
			const movementProgressFix = 0.02;
			const uFreq = xyUniforms.uFreq.value;
			const uAmp = xyUniforms.uAmp.value;
			const distortion = new THREE.Vector3(
				Math.cos(progress * Math.PI * uFreq.x + time) * uAmp.x -
					Math.cos(movementProgressFix * Math.PI * uFreq.x + time) * uAmp.x,
				Math.sin(progress * Math.PI * uFreq.y + time + Math.PI / 2) * uAmp.y -
					Math.sin(
						movementProgressFix * Math.PI * uFreq.y + time + Math.PI / 2,
					) *
						uAmp.y,
				0,
			);
			const lookAtAmp = new THREE.Vector3(2, 0.4, 1);
			const lookAtOffset = new THREE.Vector3(0, 0, -3);
			return distortion.multiply(lookAtAmp).add(lookAtOffset);
		},
	},
	LongRaceDistortion: {
		uniforms: LongRaceUniforms,
		getDistortion: `
      uniform vec2 uFreq;
      uniform vec2 uAmp;
      #define PI 3.14159265358979
      vec3 getDistortion(float progress){
        float camProgress = 0.0125;
        return vec3( 
          sin(progress * PI * uFreq.x + uTime) * uAmp.x - sin(camProgress * PI * uFreq.x + uTime) * uAmp.x,
          sin(progress * PI * uFreq.y + uTime) * uAmp.y - sin(camProgress * PI * uFreq.y + uTime) * uAmp.y,
          0.
        );
      }
    `,
		getJS: (progress: number, time: number) => {
			const camProgress = 0.0125;
			const uFreq = LongRaceUniforms.uFreq.value;
			const uAmp = LongRaceUniforms.uAmp.value;
			const distortion = new THREE.Vector3(
				Math.sin(progress * Math.PI * uFreq.x + time) * uAmp.x -
					Math.sin(camProgress * Math.PI * uFreq.x + time) * uAmp.x,
				Math.sin(progress * Math.PI * uFreq.y + time) * uAmp.y -
					Math.sin(camProgress * Math.PI * uFreq.y + time) * uAmp.y,
				0,
			);
			const lookAtAmp = new THREE.Vector3(1, 1, 0);
			const lookAtOffset = new THREE.Vector3(0, 0, -5);
			return distortion.multiply(lookAtAmp).add(lookAtOffset);
		},
	},
	turbulentDistortion: {
		uniforms: turbulentUniforms,
		getDistortion: `
      uniform vec4 uFreq;
      uniform vec4 uAmp;
      float nsin(float val){
        return sin(val) * 0.5 + 0.5;
      }
      #define PI 3.14159265358979
      float getDistortionX(float progress){
        return (
          cos(PI * progress * uFreq.r + uTime) * uAmp.r +
          pow(cos(PI * progress * uFreq.g + uTime * (uFreq.g / uFreq.r)), 2. ) * uAmp.g
        );
      }
      float getDistortionY(float progress){
        return (
          -nsin(PI * progress * uFreq.b + uTime) * uAmp.b +
          -pow(nsin(PI * progress * uFreq.a + uTime / (uFreq.b / uFreq.a)), 5.) * uAmp.a
        );
      }
      vec3 getDistortion(float progress){
        return vec3(
          getDistortionX(progress) - getDistortionX(0.0125),
          getDistortionY(progress) - getDistortionY(0.0125),
          0.
        );
      }
    `,
		getJS: (progress: number, time: number) => {
			const uFreq = turbulentUniforms.uFreq.value;
			const uAmp = turbulentUniforms.uAmp.value;

			const getX = (p: number) =>
				Math.cos(Math.PI * p * uFreq.x + time) * uAmp.x +
				Math.pow(
					Math.cos(Math.PI * p * uFreq.y + time * (uFreq.y / uFreq.x)),
					2,
				) *
					uAmp.y;

			const getY = (p: number) =>
				-nsin(Math.PI * p * uFreq.z + time) * uAmp.z -
				Math.pow(nsin(Math.PI * p * uFreq.w + time / (uFreq.z / uFreq.w)), 5) *
					uAmp.w;

			const distortion = new THREE.Vector3(
				getX(progress) - getX(progress + 0.007),
				getY(progress) - getY(progress + 0.007),
				0,
			);
			const lookAtAmp = new THREE.Vector3(-2, -5, 0);
			const lookAtOffset = new THREE.Vector3(0, 0, -10);
			return distortion.multiply(lookAtAmp).add(lookAtOffset);
		},
	},
	turbulentDistortionStill: {
		uniforms: turbulentUniforms,
		getDistortion: `
      uniform vec4 uFreq;
      uniform vec4 uAmp;
      float nsin(float val){
        return sin(val) * 0.5 + 0.5;
      }
      #define PI 3.14159265358979
      float getDistortionX(float progress){
        return (
          cos(PI * progress * uFreq.r) * uAmp.r +
          pow(cos(PI * progress * uFreq.g * (uFreq.g / uFreq.r)), 2. ) * uAmp.g
        );
      }
      float getDistortionY(float progress){
        return (
          -nsin(PI * progress * uFreq.b) * uAmp.b +
          -pow(nsin(PI * progress * uFreq.a / (uFreq.b / uFreq.a)), 5.) * uAmp.a
        );
      }
      vec3 getDistortion(float progress){
        return vec3(
          getDistortionX(progress) - getDistortionX(0.02),
          getDistortionY(progress) - getDistortionY(0.02),
          0.
        );
      }
    `,
	},
	deepDistortionStill: {
		uniforms: deepUniforms,
		getDistortion: `
      uniform vec4 uFreq;
      uniform vec4 uAmp;
      uniform vec2 uPowY;
      float nsin(float val){
        return sin(val) * 0.5 + 0.5;
      }
      #define PI 3.14159265358979
      float getDistortionX(float progress){
        return (
          sin(progress * PI * uFreq.x) * uAmp.x * 2.
        );
      }
      float getDistortionY(float progress){
        return (
          pow(abs(progress * uPowY.x), uPowY.y) + sin(progress * PI * uFreq.y) * uAmp.y
        );
      }
      vec3 getDistortion(float progress){
        return vec3(
          getDistortionX(progress) - getDistortionX(0.02),
          getDistortionY(progress) - getDistortionY(0.05),
          0.
        );
      }
    `,
	},
	deepDistortion: {
		uniforms: deepUniforms,
		getDistortion: `
      uniform vec4 uFreq;
      uniform vec4 uAmp;
      uniform vec2 uPowY;
      float nsin(float val){
        return sin(val) * 0.5 + 0.5;
      }
      #define PI 3.14159265358979
      float getDistortionX(float progress){
        return (
          sin(progress * PI * uFreq.x + uTime) * uAmp.x
        );
      }
      float getDistortionY(float progress){
        return (
          pow(abs(progress * uPowY.x), uPowY.y) + sin(progress * PI * uFreq.y + uTime) * uAmp.y
        );
      }
      vec3 getDistortion(float progress){
        return vec3(
          getDistortionX(progress) - getDistortionX(0.02),
          getDistortionY(progress) - getDistortionY(0.02),
          0.
        );
      }
    `,
		getJS: (progress: number, time: number) => {
			const uFreq = deepUniforms.uFreq.value;
			const uAmp = deepUniforms.uAmp.value;
			const uPowY = deepUniforms.uPowY.value;

			const getX = (p: number) =>
				Math.sin(p * Math.PI * uFreq.x + time) * uAmp.x;
			const getY = (p: number) =>
				Math.pow(p * uPowY.x, uPowY.y) +
				Math.sin(p * Math.PI * uFreq.y + time) * uAmp.y;

			const distortion = new THREE.Vector3(
				getX(progress) - getX(progress + 0.01),
				getY(progress) - getY(progress + 0.01),
				0,
			);
			const lookAtAmp = new THREE.Vector3(-2, -4, 0);
			const lookAtOffset = new THREE.Vector3(0, 0, -10);
			return distortion.multiply(lookAtAmp).add(lookAtOffset);
		},
	},
};

const distortion_uniforms = {
	uDistortionX: { value: new THREE.Vector2(80, 3) },
	uDistortionY: { value: new THREE.Vector2(-40, 2.5) },
};

const distortion_vertex = `
  #define PI 3.14159265358979
  uniform vec2 uDistortionX;
  uniform vec2 uDistortionY;
  float nsin(float val){
    return sin(val) * 0.5 + 0.5;
  }
  vec3 getDistortion(float progress){
    progress = clamp(progress, 0., 1.);
    float xAmp = uDistortionX.r;
    float xFreq = uDistortionX.g;
    float yAmp = uDistortionY.r;
    float yFreq = uDistortionY.g;
    return vec3( 
      xAmp * nsin(progress * PI * xFreq - PI / 2.),
      yAmp * nsin(progress * PI * yFreq - PI / 2.),
      0.
    );
  }
`;

function random(base: number | [number, number]): number {
	if (Array.isArray(base)) {
		return Math.random() * (base[1] - base[0]) + base[0];
	}
	return Math.random() * base;
}

function pickRandom<T>(arr: T | T[]): T {
	if (Array.isArray(arr)) {
		return arr[Math.floor(Math.random() * arr.length)];
	}
	return arr;
}

function lerp(
	current: number,
	target: number,
	speed = 0.1,
	limit = 0.001,
): number {
	let change = (target - current) * speed;
	if (Math.abs(change) < limit) {
		change = target - current;
	}
	return change;
}

class CarLights {
	webgl: App;
	options: HyperspeedOptions;
	colors: number[] | THREE.Color;
	speed: [number, number];
	fade: THREE.Vector2;
	mesh!: THREE.Mesh<THREE.InstancedBufferGeometry, THREE.ShaderMaterial>;

	constructor(
		webgl: App,
		options: HyperspeedOptions,
		colors: number[] | THREE.Color,
		speed: [number, number],
		fade: THREE.Vector2,
	) {
		this.webgl = webgl;
		this.options = options;
		this.colors = colors;
		this.speed = speed;
		this.fade = fade;
	}

	init() {
		const options = this.options;
		const curve = new THREE.LineCurve3(
			new THREE.Vector3(0, 0, 0),
			new THREE.Vector3(0, 0, -1),
		);
		const geometry = new THREE.TubeGeometry(curve, 40, 1, 8, false);

		const instanced = new THREE.InstancedBufferGeometry();

		for (const key in geometry.attributes) {
			instanced.setAttribute(key, geometry.attributes[key]);
		}
		if (geometry.index) {
			instanced.setIndex(geometry.index);
		}
		instanced.instanceCount = options.lightPairsPerRoadWay * 2;

		const laneWidth = options.roadWidth / options.lanesPerRoad;

		const aOffset: number[] = [];
		const aMetrics: number[] = [];
		const aColor: number[] = [];

		let colorArray: THREE.Color[];
		if (Array.isArray(this.colors)) {
			colorArray = this.colors.map((c) => new THREE.Color(c));
		} else {
			colorArray = [new THREE.Color(this.colors)];
		}

		for (let i = 0; i < options.lightPairsPerRoadWay; i++) {
			const radius = random(options.carLightsRadius);
			const length = random(options.carLightsLength);
			const spd = random(this.speed);

			const carLane = i % options.lanesPerRoad;
			let laneX = carLane * laneWidth - options.roadWidth / 2 + laneWidth / 2;

			const carWidth = random(options.carWidthPercentage) * laneWidth;
			const carShiftX = random(options.carShiftX) * laneWidth;
			laneX += carShiftX;

			const offsetY = random(options.carFloorSeparation) + radius * 1.3;
			const offsetZ = -random(options.length);

			aOffset.push(laneX - carWidth / 2);
			aOffset.push(offsetY);
			aOffset.push(offsetZ);

			aOffset.push(laneX + carWidth / 2);
			aOffset.push(offsetY);
			aOffset.push(offsetZ);

			aMetrics.push(radius);
			aMetrics.push(length);
			aMetrics.push(spd);

			aMetrics.push(radius);
			aMetrics.push(length);
			aMetrics.push(spd);

			const color = pickRandom<THREE.Color>(colorArray);
			aColor.push(color.r);
			aColor.push(color.g);
			aColor.push(color.b);

			aColor.push(color.r);
			aColor.push(color.g);
			aColor.push(color.b);
		}

		instanced.setAttribute(
			"aOffset",
			new THREE.InstancedBufferAttribute(new Float32Array(aOffset), 3, false),
		);
		instanced.setAttribute(
			"aMetrics",
			new THREE.InstancedBufferAttribute(new Float32Array(aMetrics), 3, false),
		);
		instanced.setAttribute(
			"aColor",
			new THREE.InstancedBufferAttribute(new Float32Array(aColor), 3, false),
		);

		const material = new THREE.ShaderMaterial({
			fragmentShader: carLightsFragment,
			vertexShader: carLightsVertex,
			transparent: true,
			uniforms: Object.assign(
				{
					uTime: { value: 0 },
					uTravelLength: { value: options.length },
					uFade: { value: this.fade },
				},
				this.webgl.fogUniforms,
				(typeof this.options.distortion === "object"
					? this.options.distortion.uniforms
					: {}) || {},
			),
		});

		material.onBeforeCompile = (shader) => {
			shader.vertexShader = shader.vertexShader.replace(
				"#include <getDistortion_vertex>",
				typeof this.options.distortion === "object"
					? this.options.distortion.getDistortion
					: "",
			);
		};

		const mesh = new THREE.Mesh(instanced, material);
		mesh.frustumCulled = false;
		this.webgl.scene.add(mesh);
		this.mesh = mesh;
	}

	update(time: number) {
		if (this.mesh.material.uniforms.uTime) {
			this.mesh.material.uniforms.uTime.value = time;
		}
	}
}

const carLightsFragment = `
  #define USE_FOG;
  ${THREE.ShaderChunk["fog_pars_fragment"]}
  varying vec3 vColor;
  varying vec2 vUv; 
  uniform vec2 uFade;
  void main() {
    vec3 color = vec3(vColor);
    float alpha = smoothstep(uFade.x, uFade.y, vUv.x);
    gl_FragColor = vec4(color, alpha);
    if (gl_FragColor.a < 0.0001) discard;
    ${THREE.ShaderChunk["fog_fragment"]}
  }
`;

const carLightsVertex = `
  #define USE_FOG;
  ${THREE.ShaderChunk["fog_pars_vertex"]}
  attribute vec3 aOffset;
  attribute vec3 aMetrics;
  attribute vec3 aColor;
  uniform float uTravelLength;
  uniform float uTime;
  varying vec2 vUv; 
  varying vec3 vColor; 
  #include <getDistortion_vertex>
  void main() {
    vec3 transformed = position.xyz;
    float radius = aMetrics.r;
    float myLength = aMetrics.g;
    float speed = aMetrics.b;

    transformed.xy *= radius;
    transformed.z *= myLength;

    transformed.z += myLength - mod(uTime * speed + aOffset.z, uTravelLength);
    transformed.xy += aOffset.xy;

    float progress = abs(transformed.z / uTravelLength);
    transformed.xyz += getDistortion(progress);

    vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.);
    gl_Position = projectionMatrix * mvPosition;
    vUv = uv;
    vColor = aColor;
    ${THREE.ShaderChunk["fog_vertex"]}
  }
`;

class LightsSticks {
	webgl: App;
	options: HyperspeedOptions;
	mesh!: THREE.Mesh<THREE.InstancedBufferGeometry, THREE.ShaderMaterial>;

	constructor(webgl: App, options: HyperspeedOptions) {
		this.webgl = webgl;
		this.options = options;
	}

	init() {
		const options = this.options;
		const geometry = new THREE.PlaneGeometry(1, 1);
		const instanced = new THREE.InstancedBufferGeometry();

		for (const key in geometry.attributes) {
			instanced.setAttribute(key, geometry.attributes[key]);
		}
		if (geometry.index) {
			instanced.setIndex(geometry.index);
		}
		const totalSticks = options.totalSideLightSticks;
		instanced.instanceCount = totalSticks;

		const stickoffset = options.length / (totalSticks - 1);
		const aOffset: number[] = [];
		const aColor: number[] = [];
		const aMetrics: number[] = [];

		let colorArray: THREE.Color[];
		if (Array.isArray(options.colors.sticks)) {
			colorArray = options.colors.sticks.map((c) => new THREE.Color(c));
		} else {
			colorArray = [new THREE.Color(options.colors.sticks)];
		}

		for (let i = 0; i < totalSticks; i++) {
			const width = random(options.lightStickWidth);
			const height = random(options.lightStickHeight);
			aOffset.push((i - 1) * stickoffset * 2 + stickoffset * Math.random());

			const color = pickRandom<THREE.Color>(colorArray);
			aColor.push(color.r);
			aColor.push(color.g);
			aColor.push(color.b);

			aMetrics.push(width);
			aMetrics.push(height);
		}

		instanced.setAttribute(
			"aOffset",
			new THREE.InstancedBufferAttribute(new Float32Array(aOffset), 1, false),
		);
		instanced.setAttribute(
			"aColor",
			new THREE.InstancedBufferAttribute(new Float32Array(aColor), 3, false),
		);
		instanced.setAttribute(
			"aMetrics",
			new THREE.InstancedBufferAttribute(new Float32Array(aMetrics), 2, false),
		);

		const material = new THREE.ShaderMaterial({
			fragmentShader: sideSticksFragment,
			vertexShader: sideSticksVertex,
			side: THREE.DoubleSide,
			uniforms: Object.assign(
				{
					uTravelLength: { value: options.length },
					uTime: { value: 0 },
				},
				this.webgl.fogUniforms,
				(typeof options.distortion === "object"
					? options.distortion.uniforms
					: {}) || {},
			),
		});

		material.onBeforeCompile = (shader) => {
			shader.vertexShader = shader.vertexShader.replace(
				"#include <getDistortion_vertex>",
				typeof this.options.distortion === "object"
					? this.options.distortion.getDistortion
					: "",
			);
		};

		const mesh = new THREE.Mesh(instanced, material);
		mesh.frustumCulled = false;
		this.webgl.scene.add(mesh);
		this.mesh = mesh;
	}

	update(time: number) {
		if (this.mesh.material.uniforms.uTime) {
			this.mesh.material.uniforms.uTime.value = time;
		}
	}
}

const sideSticksVertex = `
  #define USE_FOG;
  ${THREE.ShaderChunk["fog_pars_vertex"]}
  attribute float aOffset;
  attribute vec3 aColor;
  attribute vec2 aMetrics;
  uniform float uTravelLength;
  uniform float uTime;
  varying vec3 vColor;
  mat4 rotationY( in float angle ) {
    return mat4(
      cos(angle),		0,		sin(angle),	0,
      0,		        1.0,	0,			0,
      -sin(angle),	    0,		cos(angle),	0,
      0, 		        0,		0,			1
    );
  }
  #include <getDistortion_vertex>
  void main(){
    vec3 transformed = position.xyz;
    float width = aMetrics.x;
    float height = aMetrics.y;

    transformed.xy *= vec2(width, height);
    float time = mod(uTime * 60. * 2. + aOffset, uTravelLength);

    transformed = (rotationY(3.14/2.) * vec4(transformed,1.)).xyz;
    transformed.z += - uTravelLength + time;

    float progress = abs(transformed.z / uTravelLength);
    transformed.xyz += getDistortion(progress);

    transformed.y += height / 2.;
    transformed.x += -width / 2.;
    vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.);
    gl_Position = projectionMatrix * mvPosition;
    vColor = aColor;
    ${THREE.ShaderChunk["fog_vertex"]}
  }
`;

const sideSticksFragment = `
  #define USE_FOG;
  ${THREE.ShaderChunk["fog_pars_fragment"]}
  varying vec3 vColor;
  void main(){
    vec3 color = vec3(vColor);
    gl_FragColor = vec4(color,1.);
    ${THREE.ShaderChunk["fog_fragment"]}
  }
`;

class Road {
	webgl: App;
	options: HyperspeedOptions;
	uTime: { value: number };
	leftRoadWay!: THREE.Mesh;
	rightRoadWay!: THREE.Mesh;
	island!: THREE.Mesh;

	constructor(webgl: App, options: HyperspeedOptions) {
		this.webgl = webgl;
		this.options = options;
		this.uTime = { value: 0 };
	}

	createPlane(side: number, width: number, isRoad: boolean) {
		const options = this.options;
		const segments = 100;
		const geometry = new THREE.PlaneGeometry(
			isRoad ? options.roadWidth : options.islandWidth,
			options.length,
			20,
			segments,
		);

		let uniforms: Record<string, { value: unknown }> = {
			uTravelLength: { value: options.length },
			uColor: {
				value: new THREE.Color(
					isRoad ? options.colors.roadColor : options.colors.islandColor,
				),
			},
			uTime: this.uTime,
		};

		if (isRoad) {
			uniforms = Object.assign(uniforms, {
				uLanes: { value: options.lanesPerRoad },
				uBrokenLinesColor: {
					value: new THREE.Color(options.colors.brokenLines),
				},
				uShoulderLinesColor: {
					value: new THREE.Color(options.colors.shoulderLines),
				},
				uShoulderLinesWidthPercentage: {
					value: options.shoulderLinesWidthPercentage,
				},
				uBrokenLinesLengthPercentage: {
					value: options.brokenLinesLengthPercentage,
				},
				uBrokenLinesWidthPercentage: {
					value: options.brokenLinesWidthPercentage,
				},
			});
		}

		const material = new THREE.ShaderMaterial({
			fragmentShader: isRoad ? roadFragment : islandFragment,
			vertexShader: roadVertex,
			side: THREE.DoubleSide,
			uniforms: Object.assign(
				uniforms,
				this.webgl.fogUniforms,
				(typeof options.distortion === "object"
					? options.distortion.uniforms
					: {}) || {},
			),
		});

		material.onBeforeCompile = (shader) => {
			shader.vertexShader = shader.vertexShader.replace(
				"#include <getDistortion_vertex>",
				typeof this.options.distortion === "object"
					? this.options.distortion.getDistortion
					: "",
			);
		};

		const mesh = new THREE.Mesh(geometry, material);
		mesh.rotation.x = -Math.PI / 2;
		mesh.position.z = -options.length / 2;
		mesh.position.x +=
			(this.options.islandWidth / 2 + options.roadWidth / 2) * side;

		this.webgl.scene.add(mesh);
		return mesh;
	}

	init() {
		this.leftRoadWay = this.createPlane(-1, this.options.roadWidth, true);
		this.rightRoadWay = this.createPlane(1, this.options.roadWidth, true);
		this.island = this.createPlane(0, this.options.islandWidth, false);
	}

	update(time: number) {
		this.uTime.value = time;
	}
}

const roadBaseFragment = `
  #define USE_FOG;
  varying vec2 vUv; 
  uniform vec3 uColor;
  uniform float uTime;
  #include <roadMarkings_vars>
  ${THREE.ShaderChunk["fog_pars_fragment"]}
  void main() {
    vec2 uv = vUv;
    vec3 color = vec3(uColor);
    #include <roadMarkings_fragment>
    gl_FragColor = vec4(color, 1.);
    ${THREE.ShaderChunk["fog_fragment"]}
  }
`;

const islandFragment = roadBaseFragment
	.replace("#include <roadMarkings_fragment>", "")
	.replace("#include <roadMarkings_vars>", "");

const roadMarkings_vars = `
  uniform float uLanes;
  uniform vec3 uBrokenLinesColor;
  uniform vec3 uShoulderLinesColor;
  uniform float uShoulderLinesWidthPercentage;
  uniform float uBrokenLinesWidthPercentage;
  uniform float uBrokenLinesLengthPercentage;
  highp float random(vec2 co) {
    highp float a = 12.9898;
    highp float b = 78.233;
    highp float c = 43758.5453;
    highp float dt = dot(co.xy, vec2(a, b));
    highp float sn = mod(dt, 3.14);
    return fract(sin(sn) * c);
  }
`;

const roadMarkings_fragment = `
  uv.y = mod(uv.y + uTime * 0.05, 1.);
  float laneWidth = 1.0 / uLanes;
  float brokenLineWidth = laneWidth * uBrokenLinesWidthPercentage;
  float laneEmptySpace = 1. - uBrokenLinesLengthPercentage;

  float brokenLines = step(1.0 - brokenLineWidth, fract(uv.x * 2.0)) * step(laneEmptySpace, fract(uv.y * 10.0));
  float sideLines = step(1.0 - brokenLineWidth, fract((uv.x - laneWidth * (uLanes - 1.0)) * 2.0)) + step(brokenLineWidth, uv.x);

  brokenLines = mix(brokenLines, sideLines, uv.x);
`;

const roadFragment = roadBaseFragment
	.replace("#include <roadMarkings_fragment>", roadMarkings_fragment)
	.replace("#include <roadMarkings_vars>", roadMarkings_vars);

const roadVertex = `
  #define USE_FOG;
  uniform float uTime;
  ${THREE.ShaderChunk["fog_pars_vertex"]}
  uniform float uTravelLength;
  varying vec2 vUv; 
  #include <getDistortion_vertex>
  void main() {
    vec3 transformed = position.xyz;
    vec3 distortion = getDistortion((transformed.y + uTravelLength / 2.) / uTravelLength);
    transformed.x += distortion.x;
    transformed.z += distortion.y;
    transformed.y += -1. * distortion.z;  
    
    vec4 mvPosition = modelViewMatrix * vec4(transformed, 1.);
    gl_Position = projectionMatrix * mvPosition;
    vUv = uv;
    ${THREE.ShaderChunk["fog_vertex"]}
  }
`;

function resizeRendererToDisplaySize(
	renderer: THREE.WebGLRenderer,
	setSize: (width: number, height: number, updateStyle: boolean) => void,
) {
	const canvas = renderer.domElement;
	const container = canvas.parentElement;
	if (!container) return false;

	const width = container.clientWidth;
	const height = container.clientHeight;
	const needResize = canvas.width !== width || canvas.height !== height;
	if (needResize) {
		setSize(width, height, false);
	}
	return needResize;
}

class App {
	container: HTMLElement;
	options: HyperspeedOptions;
	renderer: THREE.WebGLRenderer;
	composer: EffectComposer;
	camera: THREE.PerspectiveCamera;
	scene: THREE.Scene;
	renderPass!: RenderPass;
	bloomPass!: EffectPass;
	clock: THREE.Clock;
	assets: Record<string, unknown>;
	disposed: boolean;
	road: Road;
	leftCarLights: CarLights;
	rightCarLights: CarLights;
	leftSticks: LightsSticks;
	fogUniforms: Record<string, { value: unknown }>;
	fovTarget: number;
	speedUpTarget: number;
	speedUp: number;
	timeOffset: number;
	resizeObserver?: ResizeObserver;

	constructor(container: HTMLElement, options: HyperspeedOptions) {
		this.options = options;
		if (!this.options.distortion) {
			this.options.distortion = {
				uniforms: distortion_uniforms,
				getDistortion: distortion_vertex,
			};
		}
		this.container = container;

		this.renderer = new THREE.WebGLRenderer({
			antialias: false,
			alpha: true,
		});
		this.renderer.setSize(container.offsetWidth, container.offsetHeight, false);
		this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
		this.renderer.domElement.style.display = "block";
		this.renderer.domElement.style.width = "100%";
		this.renderer.domElement.style.height = "100%";

		this.composer = new EffectComposer(this.renderer);
		container.appendChild(this.renderer.domElement);

		this.camera = new THREE.PerspectiveCamera(
			options.fov,
			container.offsetWidth / container.offsetHeight,
			0.1,
			10000,
		);
		this.camera.position.z = -5;
		this.camera.position.y = 8;
		this.camera.position.x = 0;

		this.scene = new THREE.Scene();
		this.scene.background = null;

		const fog = new THREE.Fog(
			options.colors.background,
			options.length * 0.2,
			options.length * 500,
		);
		this.scene.fog = fog;

		this.fogUniforms = {
			fogColor: { value: fog.color },
			fogNear: { value: fog.near },
			fogFar: { value: fog.far },
		};

		this.clock = new THREE.Clock();
		this.assets = {};
		this.disposed = false;

		this.road = new Road(this, options);
		this.leftCarLights = new CarLights(
			this,
			options,
			options.colors.leftCars,
			options.movingAwaySpeed,
			new THREE.Vector2(0, 1 - options.carLightsFade),
		);
		this.rightCarLights = new CarLights(
			this,
			options,
			options.colors.rightCars,
			options.movingCloserSpeed,
			new THREE.Vector2(1, 0 + options.carLightsFade),
		);
		this.leftSticks = new LightsSticks(this, options);

		this.fovTarget = options.fov;
		this.speedUpTarget = 0;
		this.speedUp = 0;
		this.timeOffset = 0;

		this.tick = this.tick.bind(this);
		this.init = this.init.bind(this);
		this.setSize = this.setSize.bind(this);
		this.onMouseDown = this.onMouseDown.bind(this);
		this.onMouseUp = this.onMouseUp.bind(this);
		this.onWindowResize = this.onWindowResize.bind(this);

		if (typeof ResizeObserver !== "undefined") {
			const resizeObserver = new ResizeObserver(() => {
				this.onWindowResize();
			});
			resizeObserver.observe(container);

			this.resizeObserver = resizeObserver;
		} else {
			window.addEventListener("resize", this.onWindowResize);
		}
	}

	onWindowResize() {
		const width = this.container.offsetWidth;
		const height = this.container.offsetHeight;

		this.renderer.setSize(width, height, false);
		this.camera.aspect = width / height;
		this.camera.updateProjectionMatrix();
		this.composer.setSize(width, height);
	}

	initPasses() {
		this.renderPass = new RenderPass(this.scene, this.camera);
		this.bloomPass = new EffectPass(
			this.camera,
			new BloomEffect({
				luminanceThreshold: 0.2,
				luminanceSmoothing: 0,
				resolutionScale: 1,
			}),
		);

		const smaaPass = new EffectPass(
			this.camera,
			new SMAAEffect({
				preset: SMAAPreset.MEDIUM,
			}),
		);
		this.renderPass.renderToScreen = false;
		this.bloomPass.renderToScreen = false;
		smaaPass.renderToScreen = true;

		this.composer.addPass(this.renderPass);
		this.composer.addPass(this.bloomPass);
		this.composer.addPass(smaaPass);
	}

	loadAssets(): Promise<void> {
		const assets = this.assets as {
			smaa: { search?: HTMLImageElement; area?: HTMLImageElement };
		};
		return new Promise((resolve) => {
			const manager = new THREE.LoadingManager(resolve);

			const searchImage = new Image();
			const areaImage = new Image();
			assets.smaa = {};

			searchImage.addEventListener("load", function () {
				assets.smaa.search = this;
				manager.itemEnd("smaa-search");
			});

			areaImage.addEventListener("load", function () {
				assets.smaa.area = this;
				manager.itemEnd("smaa-area");
			});

			manager.itemStart("smaa-search");
			manager.itemStart("smaa-area");

			searchImage.src = SMAAEffect.searchImageDataURL;
			areaImage.src = SMAAEffect.areaImageDataURL;
		});
	}

	init() {
		this.initPasses();
		const options = this.options;
		this.road.init();
		this.leftCarLights.init();
		this.leftCarLights.mesh.position.setX(
			-options.roadWidth / 2 - options.islandWidth / 2,
		);

		this.rightCarLights.init();
		this.rightCarLights.mesh.position.setX(
			options.roadWidth / 2 + options.islandWidth / 2,
		);

		this.leftSticks.init();
		this.leftSticks.mesh.position.setX(
			-(options.roadWidth + options.islandWidth / 2),
		);

		this.container.addEventListener("mousedown", this.onMouseDown);
		this.container.addEventListener("mouseup", this.onMouseUp);
		this.container.addEventListener("mouseout", this.onMouseUp);

		this.tick();
	}

	onMouseDown(ev: MouseEvent) {
		if (this.options.onSpeedUp) this.options.onSpeedUp(ev);
		this.fovTarget = this.options.fovSpeedUp;
		this.speedUpTarget = this.options.speedUp;
	}

	onMouseUp(ev: MouseEvent) {
		if (this.options.onSlowDown) this.options.onSlowDown(ev);
		this.fovTarget = this.options.fov;
		this.speedUpTarget = 0;
	}

	update(delta: number) {
		const lerpPercentage = Math.exp(-(-60 * Math.log2(1 - 0.1)) * delta);
		this.speedUp += lerp(
			this.speedUp,
			this.speedUpTarget,
			lerpPercentage,
			0.00001,
		);
		this.timeOffset += this.speedUp * delta;
		const time = this.clock.elapsedTime + this.timeOffset;

		this.rightCarLights.update(time);
		this.leftCarLights.update(time);
		this.leftSticks.update(time);
		this.road.update(time);

		let updateCamera = false;
		const fovChange = lerp(this.camera.fov, this.fovTarget, lerpPercentage);
		if (fovChange !== 0) {
			this.camera.fov += fovChange * delta * 6;
			updateCamera = true;
		}

		if (
			typeof this.options.distortion === "object" &&
			this.options.distortion.getJS
		) {
			const distortion = this.options.distortion.getJS(0.025, time);
			this.camera.lookAt(
				new THREE.Vector3(
					this.camera.position.x + distortion.x,
					this.camera.position.y + distortion.y,
					this.camera.position.z + distortion.z,
				),
			);
			updateCamera = true;
		}

		if (updateCamera) {
			this.camera.updateProjectionMatrix();
		}
	}

	render(delta: number) {
		this.composer.render(delta);
	}

	dispose() {
		this.disposed = true;

		if (this.resizeObserver) {
			this.resizeObserver.disconnect();
		} else {
			window.removeEventListener("resize", this.onWindowResize);
		}

		if (this.renderer) {
			this.renderer.dispose();
		}
		if (this.composer) {
			this.composer.dispose();
		}
		if (this.scene) {
			this.scene.clear();
		}

		if (this.container) {
			this.container.removeEventListener("mousedown", this.onMouseDown);
			this.container.removeEventListener("mouseup", this.onMouseUp);
			this.container.removeEventListener("mouseout", this.onMouseUp);
		}
	}

	setSize(width: number, height: number, updateStyles: boolean) {
		this.composer.setSize(width, height, updateStyles);
	}

	tick() {
		if (this.disposed || !this) return;

		const containerWidth = this.container.offsetWidth;
		const containerHeight = this.container.offsetHeight;

		if (resizeRendererToDisplaySize(this.renderer, this.setSize)) {
			this.camera.aspect = containerWidth / containerHeight;
			this.camera.updateProjectionMatrix();
		}

		const delta = this.clock.getDelta();
		this.render(delta);
		this.update(delta);
		requestAnimationFrame(this.tick);
	}
}

const initHyperspeed = async () => {
	if (appRef) {
		appRef.dispose();
		const container = hyperspeedContainer.value;
		if (container) {
			while (container.firstChild) {
				container.removeChild(container.firstChild);
			}
		}
	}

	const container = hyperspeedContainer.value;
	if (!container) return;

	const mergedOptions: HyperspeedOptions = {
		...defaultOptions,
		...props.effectOptions,
	};

	const options = { ...mergedOptions };
	if (typeof options.distortion === "string") {
		options.distortion = distortions[options.distortion];
	}

	const myApp = new App(container, options);
	appRef = myApp;
	await myApp.loadAssets();
	myApp.init();
};

onMounted(() => {
	initHyperspeed();
});

onUnmounted(() => {
	if (appRef) {
		appRef.dispose();
		appRef = null;
	}
});

watch(
	() => props.effectOptions,
	() => {
		initHyperspeed();
	},
	{ deep: true },
);
</script>

<style scoped>
.hyperspeed-container {
	width: 100%;
	height: 100%;
	position: relative;
	overflow: hidden;
}

.hyperspeed-container :deep(canvas) {
	width: 100% !important;
	height: 100% !important;
	display: block;
}
</style>
